feat(spec)!: refuse a text operator over a field whose declared type can never store a string — the contract rows for the engine door (#15661) - #15804
Conversation
… fixture, derived cases (#15661) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…banner and ADR-0087 disposition (#15661) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…ed-type door exports (#15661) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…xt-operator-declared-type-door-rows
📓 Docs Drift CheckThis PR changes 1 package(s): 11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e93e154260fa7d339c7b12612e3027ba93785eb9 && git checkout e93e154260fa7d339c7b12612e3027ba93785eb9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f501453b964e2ccede97219c3cb8d1399b47b6d1 8742baf9fef7f0d8f54ec9152bebb2bad58b1835 && git checkout -B drift-repro f501453b964e2ccede97219c3cb8d1399b47b6d1 && git merge --no-ff 8742baf9fef7f0d8f54ec9152bebb2bad58b1835
node scripts/docs-audit/affected-docs.mjs --json f501453b964e2ccede97219c3cb8d1399b47b6d1
|
Fixes #15661
Lane (1) of the maintainer ruling recorded on #15661 (director seat, decision batch #43, verbatim reply 「同意」): C-deny — the spec contract half only. The door itself (lane 2,
packages/objectql/src/engine.tsbesideINVALID_FIELD) is #15773, which is blocked by this card and consumes the table landed here. Sequencing satisfied: #14079's row landed in PR #15686 (a646120dc).What lands
packages/spec/src/data/filter-text-operator-declared-type.ts(+ its test, + the barrel line), exported on the published@objectstack/spec/dataface:TEXT_FILTER_OPERATORS— the ruling's seven, pinned equal toStringOperatorSchema's key set (a text operator declared later fails the pin loudly).TEXT_OPERATOR_DOOR_REFUSED_TYPES— the union of the six EXISTING sets (NUMERIC_VALUE_TYPES∪BOOLEAN_VALUE_TYPES∪CALENDAR_DATE_TYPES∪INSTANT_TYPES∪CLOCK_TIME_TYPES∪STRUCTURED_JSON_TYPES), spelled by reference;TEXT_OPERATOR_DOOR_PASSING_TYPES— the string-valued classes. ⛔ No new set: the class table references the exports by identity (pinned withtoBe).textOperatorDoorVerdict(field)— the pure verdict (door-refusal/passes/deferred), andFORMULA_RETURN_TYPE_AS_FIELD_TYPE.TEXT_OPERATOR_DOOR_TYPE_CLASSES— the verdict matrix, pinned as a census ofFieldType.TEXT_OPERATOR_DOOR_FIXTURE(+_FIELDS,_OBJECT) — aFieldSchema/ObjectSchema-legal probe object with one field perFieldTypemember, four typedformulafields and one untyped.TEXT_OPERATOR_DOOR_CASES— the derived case table the engine suite consumes (door-refusalrows carrycode: 'INVALID_FILTER',status: 400,mustMention: [key, declared type (+ return type), operator];passes/deferredrows assert the filter reaches the driver unchanged).Changeset:
@objectstack/specminor with the BREAKING banner (feat(spec)!:), ADR-0087 dispositionnot-required (no-migration-prescription); ⛔ no entry inmigrations/registry.ts(held by PR #15626). Generated followers regenerated by the repo tooling, never hand-edited:api-surface/data.json(+20 entries) andexport-origins/data.json(+20), zero removals.H2 — a sibling module, not an extension of the comparand table (and not a driver case-set)
The comparand table's
door-refusalrows assertparseFilterAST(filter)throws — a SYNTAX door with no field map. The ruled door is FIELD-aware, soparseFilterASTcannot host it and the rows live in a sibling keyed on the declared type. The five driver comparand suites,engine-comparand-type-door.test.tsandservice-analytics/src/comparand-shape.tsare untouched (readings below).The sibling is deliberately not named
*-conformance.tsand is not registered inscripts/check-driver-conformance.mjs'sCASE_SETS(socheck:driver-conformancedoes not read it): the census enrols DRIVERS, and drivers sit BENEATH this door — the ruling keeps #14079's row for every evaluator no door fronts, so a driver "covering" this table would assert the opposite of the ruling. Its one consumer is the engine door (#15773); the file is named for the door it declares, likefilter-comparand-type.tsandfilter-dotted-head.ts. Every case is pinned to pass the syntax door (parseFilterASTaccepts each), so a refusal can only be the field-aware door's.H1 — the census (measured at the enum,
FieldType.options.length === 49)Every
FieldTypemember appears in exactly one class row; the test fails on a member absent, judged twice, or a ghost (pinned in both directions):STRING_VALUE_TYPESautonumber(named by the ruling)SINGLE_OPTION_TYPESMULTI_OPTION_TYPESREFERENCE_VALUE_TYPESFILE_REFERENCE_TYPESNUMERIC_VALUE_TYPESBOOLEAN_VALUE_TYPESCALENDAR_DATE_TYPESINSTANT_TYPESCLOCK_TIME_TYPESSTRUCTURED_JSON_TYPESformulaRefused = 19 types, passing = 29 types,
formula= by return type. Refused ∩ passing = ∅; refused ∪ passing ∪ {formula} =FieldType(pinned).NON_TEXT_STORED_VALUE_TYPES(#14079, the SQL faces' compile-time gate) ⊂ refused, and refused ∖ it = exactly the temporal + JSON classes (pinned, H4).One derived row, not ruled by name:
FILE_REFERENCE_TYPES→passes. The ruling's criterion is "a DECLARED type that can never store a string"; a file field stores an opaque id / url string (or a legacy inline object) today and asys_fileid string after ADR-0104 D3 — never "never a string", so it is outside the refused criterion. Recorded on the class row as derived; re-judge only if D3 lands a non-string stored form.MULTI_OPTION_TYPES→passesfollows the ruling's own protection oftags(C-allow refused for breaking it); what a text operator answers over the ARRAY beneath the door is the evaluators' question, not this table's.H3 —
formulaFieldSchema.returnTypeisz.enum(['number','text','boolean','date']).optional()(re-located by symbol). Every value is itself aFieldTypemember, so a formula with a readable return type is judged as the field type its return type names —textpasses;number/boolean/dateare refused through the same sets (no second vocabulary).returnTypeabsent, or a spelling the schema does not declare, ⇒deferred(the ruling judges formula only when the type is readable at the seam). Pinned: the map's keys equal the enum's options; each verdict;summaryis refused becauseNUMERIC_VALUE_TYPES.has('summary')(verified at the set, withCOMPUTED_VALUE_TYPES.has('summary')also true — its computed nature is not the axis);autonumberpasses.H5 — operators × classes (the verdict matrix)
All seven operators (
$contains$notContains$startsWith$endsWith$icontains$like$ilike) get the same verdict per class — the ruling refuses by declared type whatever the polarity; beneath the door$notContainskeeps #14079's complement.TEXT_FILTER_OPERATORSequalsObject.keys(StringOperatorSchema.shape)(pinned as an equality, both directions).Derived cases: 53 fixture fields × 7 operators + 7 structured-JSON fields × 7 operators as DOTTED keys = 420 cases: 154
door-refusal(19 refused types + 3 refused formulas = 22 fields), 210passes(29 +formula returning text= 30 fields), 56deferred(f_formula_untyped+ 7 dotted paths). The dotted rows exist so the engine door does not re-closefilter-dotted-head's deliberate structured-JSON carve-out (#8371, live on two of three backends) by reading the head's declared type.H4 — #14079's row stays beneath the door
filter-text-conformance.tsandfield-value.zod.tsare not edited. The module's docblock states the relationship: the door refuses at the engine seam by DECLARED type (six classes); beneath it every evaluator no door fronts keeps answering the stored-value row; the SQL faces'NON_TEXT_STORED_VALUE_TYPESis narrower on purpose — temporal / JSON columns are refused at the door but answer by stored representation beneath it (#15683 is the temporal finding).ADR-0087 census (examples/** and content/docs/**)
git grepfor a text operator ($contains|$notContains|$startsWith|$endsWith|$icontains|$like|$ilike) applied to a field key: 26 occurrences, overname(13),title(5),email(4),domain(1, the docs'$endsWith: '.com'example) — all textual by declaration; zero over a non-textual declared field. Positive control: the same grep finds the 5name/email$containsoccurrences. Nothing is renamed or retired; an author's remedy is to drop or retarget the mistaken condition, which is why the disposition isno-migration-prescription(gate:check-adr-0087-registrationgreen,[BREAKING+bang] not-required (no-migration-prescription)).Verification (sha-cited)
Head
8742baf9f(branch = base7dafaaedd+ my commits + a merge oforigin/mainup to6a3cc134c, which movedpackages/spec/src/api/error-code-ledger.zod.ts, so spec was rebuilt AFTER the merge). Every reading below is on that head; exit codes captured before any pipe (redirect first); verdict lines quoted from the tools. Heavy runs went throughscripts/pm/os-verify-lock.sh(shared-box seconds, not idle-box figures).Build:
pnpm --filter @objectstack/spec build—check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. Consumer closureturbo run build --filter='PKG^...' for each of the 7 consumers --concurrency=2—Tasks: 15 successful, 15 total(VERDICT command-exit 0, held 278s).Spec (
pnpm --filter @objectstack/spec …):exec vitest run --maxWorkers=2 src/data/filter-text-operator-declared-type.test.ts—Test Files 1 passed (1) / Tests 23 passed (23).exec vitest run --maxWorkers=2(whole package) —Test Files 474 passed (474) / Tests 12734 passed (12734)(VERDICT command-exit 0).typecheck(tsc --noEmit && check:scripts-typecheck && check:test-typecheck) — exit 0;check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; the new test file IS in that program (tsc -p tsconfig.test.json --listFiles: 1 hit), so "typecheck green" covers it — the build config's**/*.test.tsexclusion does not hide it.check:generated(after the regeneration commit) — green in the gate union below; the regeneration itself:--fix: regenerating 2 of the 2 stale artifact(s)→api-surface/andexport-origins/only,+20 / +20, zero removals;check:authorable-surfacegreen,authorable-surface.base.jsonuntouched.Consumer readings (filter direction:
...@objectstack/spec= DOWNSTREAM consumers; closure built first), allexit=0at8742baf9f, one lock hold (66s):@objectstack/driver-memorymemory-comparand-type-conformance.test.ts@objectstack/driver-sqlsql-driver-comparand-type-conformance.test.ts@objectstack/driver-sqlite-wasmsqlite-wasm-comparand-type-conformance.test.ts@objectstack/driver-tursoturso-comparand-type-conformance.test.ts@objectstack/driver-mongodbmongodb-comparand-type-conformance.test.ts@objectstack/objectqlengine-comparand-type-door.test.ts@objectstack/service-analytics__tests__/comparand-door-single-source.test.tsThe new table has no consumer yet (by design — #15773 is its consumer); the built entries do export it: from
packages/objectql, CJSrequire('@objectstack/spec/data')and ESMimportboth answertextOperatorDoorVerdict: function,TEXT_OPERATOR_DOOR_CASES.length === 420, refused 19 / passing 29,verdict({type:'number'}) === 'door-refusal',verdict({type:'formula', returnType:'text'}) === 'passes'.Gates —
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackwith NO paths (derived from git off the merge base, at8742baf9f; the first derivation before the merge warned STALE TREE, so it was re-derived after): 74 commands, 72 green, 2 NOT MEASURED. Beyond the dispatch lead the derivation added (all green):check:generated,check:nul-bytes,check:type-check-coverage,check:driver-memory-census,check:engine-double-contract,check:objectui-changeset,check:objectui-pin-citations,check:pm-half-states,check:query-options-erasure,check:refd-timer-probe,check:watch-hint-literal,check:dispatcher-error-vocabulary,check-changeset-no-major,check-empty-changeset,check-closing-keyword-parity,check-comment-mask-corpus,release-rehearsal-clone --self-test; nothing in the lead was dropped. ADR-0087:✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang] not-required (no-migration-prescription).The two NOT MEASURED, both
exit 3and both printingPREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured(54 packages have nodist/in this worktree; neither script takes a scope flag):check:dual-build-cjs-loadsandcheck:type-check-debt(--re-measure). Declared narrowing: the spec-scoped half of the first is the CJS/ESM load above; the second re-measures ledgered packages my diff does not touch. Both are repo-level runsLint & Repo Gatesowns in CI.check:driver-conformanceis not in the derivation and does not read this table (see H2).Ablation
Committed first (HEAD
8742baf9f), then a trap-guarded script (trap restore EXIT INT TERM, absolute paths fromgit rev-parse --show-toplevel) mutatedTEXT_OPERATOR_DOOR_REFUSED_TYPESto an EMPTY set (the six spreads replaced by anABLATION-15661marker) and ran the WHOLE spec package under the lock:...NUMERIC_VALUE_TYPES,spread lines 1 → 0, marker 0 → 1; blobd9af49037e48…→db284cca7751….Test Files 1 failed | 472 passed | 1 skipped (474),Tests 6 failed | 12726 passed | 1 skipped; the one failing file issrc/data/filter-text-operator-declared-type.test.ts; the six reds are exactly: the refused-set equality pin, the disjoint/union-is-FieldType pin, the wider-than-NON_TEXT_STORED_VALUE_TYPESpin (H4), both H3 verdict pins (summaryand the typed formulas fell topasses), and the three-verdict count pin. (The 1 skipped is an environment-conditional test elsewhere in the package; the clean run reports 474 passed / 12734 with no skip.)dist/could carry the mutation;dist/was not rebuilt during the ablation and still holds the clean build (the CJS/ESM load check above ran against it).git checkout HEAD -- FILE(HEAD named, never bare); proven bygit hash-object= HEAD blob (d9af49037e487e4302783700b2f4317fd93379d7both sides),git diff HEAD= 0 lines, porcelain = 0 lines, marker count 0.Out of scope
$containsfamily) over a field whose DECLARED type can never store a string —INVALID_FILTER400 at the engine's field-aware door besideINVALID_FIELD(lane 2 of the #15661 C-deny ruling) #15773 carries the engine door (lane 2) — not addressed here, referenced verb-less.Generated by Claude Code